Video Thumbnail
2:26
1:40
clock icon Created with Sketch. 2 minutes

Solution: Abstraction


Jaroslaw Kuraszewicz

Hi,
WeatherService can get callable which takes city and returns json with data (or even TypedDict).
This callable knows how to get the data, so API key is attached to the callable via partial function before it is injected into WeatherService init function. In that case we can easily exchange the source of weather forecast.

Best regards
Jarek

REPLY
Andreas [ArjanCodes Team]

Thanks for your input!

Do you have any code examples?

REPLY
Jaroslaw Kuraszewicz

Hi, I was thinking about this, here you are:

type Forecast = dict[str, Any]
type ForecastRetriever = Callable[[str], Forecast]

def OpenWeatherRetriever(city: str, key: str) -> Forecast:
url = f"http://api.openweathermap.org/data/2.5/weather?q={city}&appid={key}"
response = requests.get(url, timeout=5).json()

if "main" not in response:
raise CityNotFoundError(
f"Couldn't find weather data. Check '{city}' if it exists and is correctly spelled.\n"
)

return response

class WeatherService:
def __init__(self, retriever: ForecastRetriever) -> None:
self.full_weather_forecast: Forecast = {}
self.retriever = retriever

def retrieve_forecast(self, city: str) -> None:
self.full_weather_forecast = self.retriever(city)

# other methods

def main() -> None:
city = "Utrecht"

current_retriever = functools.partial(OpenWeatherRetriever, key=API_KEY)
client = WeatherService(retriever=current_retriever)
client.retrieve_forecast(city=city)

Best regards

REPLY
Andreas [ArjanCodes Team]

Very nice, Jaroslaw!

REPLY
Alberto Miño

Hi Andreas, how are you?

Here is my solution:

*************** Code *****************

from typing import Any
from abc import ABC, abstractmethod
from dataclasses import dataclass

import requests

API_KEY = "1234567"

class CityNotFoundError(Exception):
pass

class ApiGetter(ABC):
@abstractmethod
def fetch(self, url: str) -> dict[str, Any]:
"""Method that implements request library"""

@dataclass
class RequestApiGetter(ApiGetter):
timeout_value: int = 5

@property
def timeout(self) -> int:
return self.timeout_value

@timeout.setter
def timeout(self, value: int) -> None:
if isinstance(value, int):
self.timeout_value = value
else:
self.timeout_value = int(value)

def fetch(self, url: str) -> dict[str, Any]:
return requests.get(url, timeout=self.timeout_value).json()

class WeatherService:
def __init__(self, api_key: str, api_getter: ApiGetter) -> None:
self.api_key = api_key
self.api_getter = api_getter
self.full_weather_forecast: dict[str, Any] = {}

def retrieve_forecast(self, city: str) -> None:
url = f"http://api.openweathermap.org/data/2.5/weather?q={city}&appid={self.api_key}"
response = self.api_getter.fetch(url)
if "main" not in response:
raise CityNotFoundError(
f"Couldn't find weather data. Check '{city}' if it exists and is correctly spelled.\n"
)
self.full_weather_forecast = response

def main() -> None:
api_getter = RequestApiGetter()
city="Mar del Plata"
client = WeatherService(api_key=API_KEY, api_getter=api_getter)
client.retrieve_forecast(city=city)
temp = client.full_weather_forecast["main"]["temp"] - 273.15
hum = client.full_weather_forecast["main"]["humidity"]
wind_speed = client.full_weather_forecast["wind"]["speed"]
wind_direction = client.full_weather_forecast["wind"]["deg"]
print(f"The current temperature in {city} is {temp:.1f} °C.")
print(f"The current humidity in {city} is {hum}%.")
print(
f"The current wind speed in {city} is {wind_speed} m/s from direction {wind_direction} degrees."
)

if __name__ == "__main__":
main()

********** End Code***********

Have a nice day!
Alberto.

REPLY
Andreas [ArjanCodes Team]

Nice solution!

It's good that you have introduced some abstractions with the ABC. If you want to improve this solution, add some properties for getting the temperature, wind, etc instead of using multiple nested keys.

REPLY
Bryan Farias

I am in a bit of a dillemma with this. We can either: Make a generic http client and couple the rest of the code with the URL, or put the url inside a custom http client that manages the weather urls and their formatting. Which you would recommend in what situation?

REPLY
Andreas [ArjanCodes Team]

I would recommend making a generic http client and then create an instance containing the url which then can be used where is needed. That way you can also set default header, tokens and more.

REPLY
Stanley Sims

My take on it...
-------------
from dataclasses import dataclass
from typing import Any, Protocol
import requests
import tomllib

KELVIN_TO_CELSIUS_CONV = 273.15

with open("config.toml", "rb") as f:
config = tomllib.load(f)

class CityNotFoundError(Exception):
pass

def convert_kelvin_to_celsius(temp: float) -> float:
""" Convert temperature from Kelvin to Celsius """
return temp - KELVIN_TO_CELSIUS_CONV

# This might be too general
class RequestsClient(Protocol):
def get_data(self, *args, **kwargs) -> Any:
...

class WeatherForecastData(Protocol):
""" Only temps for now but could have other data points (humidity, wind direction) """
@property
def temp(self) -> float:
...

class WeatherService(Protocol):
def retrieve_forecast(self, city: str) -> WeatherForecastData:
""" Return city's forecast data """
...

def retrieve_forecast(city: str, ws: WeatherService) -> WeatherForecastData:
""" Retrieve forecast data for city with any defined weather api service """
return ws.retrieve_forecast(city)

def key_in_path(key_path: list[str], data: dict[str, Any]) -> bool:
""" Determine key path exists in dictionary """
next_dict = data
for key in key_path:
if key not in next_dict.keys():
return False
next_dict = next_dict[key]
return True

@dataclass
class HumanRequestsClient(RequestsClient):
def get_data(self, url: str, timeout: int) -> Any:
return requests.get(url, timeout=5) # I don't even check if this fails...lazy dude

@dataclass
class OpenWeatherMapForecastData(WeatherForecastData):
base_data: dict[str, Any]

@property
def temp(self) -> float:
if not key_in_path(config["OWM_TEMP_KEY_PATH"], self.base_data):
raise KeyError(f"Invalid key path ({','.join(config['OWM_TEMP_KEY_PATH'])}) used to retrieve temperature...check api reference")

data = self.base_data
last_key = config["OWM_TEMP_KEY_PATH"][-1:][0]
for key in config["OWM_TEMP_KEY_PATH"][:-1]:
data = data[key]

return convert_kelvin_to_celsius(data[last_key])

@dataclass
class OpenWeatherMapApi(WeatherService):
base_url: str
api_key: str
rc: RequestsClient

def retrieve_forecast(self, city: str) -> OpenWeatherMapForecastData:
url = f"{self.base_url}?q={city}&appid={self.api_key}"
response = self.rc.get_data(url, timeout=5).json()
if not key_in_path(key_path=config["OWM_ROOT_KEY_PATH"], data=response):
raise CityNotFoundError(
f"Couldn't find weather data. Check '{city}' if it exists and is correctly spelled.\n"
)

return OpenWeatherMapForecastData(response)

if __name__ == "__main__":
ow_service = OpenWeatherMapApi(
base_url=config["OWM_BASE_URL"],
api_key=config["OWM_API_KEY"],
rc=HumanRequestsClient()
)
city = "Utrecht"
wfd = retrieve_forecast(city, ow_service)
print(f"The current temperature in {city} is {wfd.temp:.1f} °C.")

REPLY
Nipun

Does `RequestsClient(HttpClient)` need to be inherited from `HttpClient` Protocol?
We could define plain RequestsClient class without inheritance which satisfies structural requirement (of HttpClient Protocol) to have `get` method right?

REPLY
Arjan Egges

Hi Nipun, inheritance is not needed with protocol classes. Sometimes, it can still be handy though, especially if the classes are larger and the methods more complex. By inheriting explicitly instead of solely relying on duck typing, your IDE can perform some extra checks while you're writing the class to detect any mistakes you make when you're overriding the methods, arguments and return types.

REPLY
Show More